Skip to content

Harden local transcript explorer security - #17

Open
alexspeller wants to merge 1 commit into
drewburchfield:mainfrom
alexspeller:agent/security-hardening
Open

Harden local transcript explorer security#17
alexspeller wants to merge 1 commit into
drewburchfield:mainfrom
alexspeller:agent/security-hardening

Conversation

@alexspeller

Copy link
Copy Markdown

Summary

  • require a random session token for HTTP/API access and enforce credentials plus exact browser origins on WebSocket upgrades
  • escape transcript-derived text before rendering the supported Markdown subset, preventing stored XSS from conversation content
  • bind native runs to loopback by default and add browser security headers
  • keep the transcript-reading container on an internal-only network behind a fixed-destination localhost proxy with no transcript or database mounts
  • replace the macOS pfctl launcher with Docker-managed isolation and validate cloned conversation IDs against path traversal
  • update vulnerable transitive dependencies while retaining Safe Chain's malware and minimum-package-age checks

Why

The application indexes sensitive local Claude transcripts. Previously it trusted transcript HTML, exposed unauthenticated HTTP and WebSocket endpoints on all interfaces, and used pfctl -f in a way that could replace the host ruleset. Session import also accepted conversation IDs that could escape the intended destination path.

Impact

Startup now prints a one-time authenticated URL. It sets an HttpOnly, SameSite=Strict cookie and redirects to a clean URL. Monitoring scripts require CHAT_EXPLORER_AUTH_TOKEN so protected metrics remain accessible intentionally.

The main container remains read-only, mounts Claude data read-only, publishes no host port, and has no outbound route. Only the no-mount proxy publishes 127.0.0.1:9876.

Validation

  • mise exec -- npm ci --no-audit --no-fund
  • mise exec -- npm test -- --maxWorkers=1 — 351 tests passed
  • mise exec -- npm audit --json — 0 vulnerabilities
  • mise exec -- npm audit signatures — 292 verified registry signatures and 37 verified attestations
  • docker compose build chat-explorer
  • Compose configuration and shell/JavaScript syntax checks
  • end-to-end container smoke checks: unauthenticated HTTP 401, authenticated bootstrap 303, authenticated HTTP 200, authenticated WebSocket handshake, no app host port, no proxy mounts, and blocked outbound access from the transcript-reading container

@alexspeller
alexspeller marked this pull request as ready for review July 20, 2026 13:09

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

View 2 additional findings in Devin Review.

Open in Devin Review

Comment thread docker-compose.dev.yml
Comment on lines +62 to +63
ports:
- "127.0.0.1:9877:9876"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Live updates stop working in the dev container because the real-time connection is rejected

The real-time WebSocket connection is refused (getAllowedWebSocketOrigins at src/chats-mobile.js:1516-1521) whenever the page is opened on a host port that differs from the container's internal port, which is exactly the case for the dev setup published on 127.0.0.1:9877:9876.
Impact: In the dev container, new conversations and messages never appear live; the real-time panel silently stops updating.

Origin allowlist keyed on internal port vs. published host port

The WebSocket verifyClient (src/chats-mobile.js:1551-1562) calls authorizeWebSocketUpgrade, which requires info.origin to be an exact member of getAllowedWebSocketOrigins(). That set is built from this.port, the port the app listens on inside the container (9876). The dev proxy publishes 127.0.0.1:9877:9876 (docker-compose.dev.yml:62-63) and forwards the browser's Origin header verbatim (src/loopback-proxy.js:28). A browser loading http://localhost:9877 sends Origin: http://localhost:9877, which is not in {http://localhost:9876, http://127.0.0.1:9876, http://[::1]:9876}, so isOriginAllowed returns false and the upgrade is rejected with 403. The same breakage occurs for any user who follows the README "Change Port" instructions to remap the published port. Production works only because there the published port equals the internal port.

Prompt for agents
The WebSocket origin allowlist in src/chats-mobile.js (getAllowedWebSocketOrigins) is derived from this.port, which is the app's internal listening port (9876 inside the container). However, browsers connect through the loopback proxy on the published host port, which can differ from the internal port (e.g. 9877 in docker-compose.dev.yml, or any custom mapping per the README 'Change Port' section). Because src/loopback-proxy.js forwards the browser Origin header verbatim, the Origin (http://localhost:9877) never matches the allowlist built for port 9876, so authorizeWebSocketUpgrade returns a 403 and real-time updates break. Consider making the set of allowed WebSocket origins configurable (e.g. via an environment variable listing the externally-published origins/ports) so the dev container and custom port mappings can advertise their real browser-facing origin, rather than assuming the browser reaches the app on the same port the app listens on.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread quick-start.sh
Comment on lines 26 to +29
echo "✅ Container is already running!"
echo "📊 Access the web interface at: http://localhost:9876"
docker compose up -d
echo "📊 Authenticated access URL:"
docker compose logs --tail=30 chat-explorer | grep "Local access:" || true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Already-running container branch may print an empty access URL

In quick-start.sh:25-29, when the container is already running the script does docker compose up -d (a no-op for an unchanged running container) and then greps docker compose logs --tail=30 chat-explorer for Local access:. That log line is only emitted once at process startup, so for a container that has been running for a while it has long scrolled past the last 30 lines and the grep prints nothing. Because the token defaults to a fresh random value per container start, a user who did not export a stable CHAT_EXPLORER_AUTH_TOKEN then has no easy way to obtain the authenticated URL and cannot reach the dashboard. The newly-created-container branch (quick-start.sh:37-44) is fine because the line is within the tail window right after startup.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread docker-compose.yml
Comment on lines +99 to +101
networks:
chat-explorer-internal:
internal: true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 App container is on an internal-only network with no outbound route, which disables URL-based session import

docker-compose.yml:99-101 places the transcript-reading app on chat-explorer-internal with internal: true, so the container has no outbound route. The session-sharing import path (src/session-sharing.js downloadSession) performs an outbound fetch to allowlisted hosts (x0.at, transfer.sh, etc.). With no egress, remote session cloning will always fail from inside the default container. The PR description states outbound access is intentionally blocked, so this appears deliberate, but it does silently break the import-from-URL feature when running under the default compose file — worth confirming this trade-off is intended and documented.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant